/*******************************************************************************
 * Copyright (c) 2007, 2013 IBM Corporation and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     James Blackburn <jamesblackburn+eclipse@gmail.com> - [implementation] FileStoreTextFileBuffer eats IOException on external file save - https://bugs.eclipse.org/333660
 *******************************************************************************/
package org.eclipse.core.internal.filebuffers;

import org.eclipse.core.filebuffers.IFileBufferStatusCodes;
import org.eclipse.core.filebuffers.ITextFileBuffer;
import org.eclipse.core.filebuffers.LocationKind;
import org.eclipse.core.filesystem.EFS;
import org.eclipse.core.filesystem.IFileInfo;
import org.eclipse.core.filesystem.IFileStore;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.QualifiedName;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.content.IContentDescription;
import org.eclipse.core.runtime.content.IContentType;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.source.IAnnotationModel;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.SequenceInputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.UnmappableCharacterException;
import java.nio.charset.UnsupportedCharsetException;

/**
 * @since 3.3 (previously available as JavaTextFileBuffer since 3.3)
 */
public class FileStoreTextFileBuffer extends FileStoreFileBuffer implements ITextFileBuffer {


	private class DocumentListener implements IDocumentListener {

		/*
		 * @see org.eclipse.jface.text.IDocumentListener#documentAboutToBeChanged(org.eclipse.jface.text.DocumentEvent)
		 */
		public void documentAboutToBeChanged(DocumentEvent event) {
		}

		/*
		 * @see org.eclipse.jface.text.IDocumentListener#documentChanged(org.eclipse.jface.text.DocumentEvent)
		 */
		public void documentChanged(DocumentEvent event) {
			fCanBeSaved= true;
			removeFileBufferContentListeners();
			fManager.fireDirtyStateChanged(FileStoreTextFileBuffer.this, fCanBeSaved);
		}
	}

	/**
	 * Reader chunk size.
	 */
	private static final int READER_CHUNK_SIZE= 2048;
	/**
	 * Buffer size.
	 */
	private static final int BUFFER_SIZE= 8 * READER_CHUNK_SIZE;
	/**
	 * Constant for representing the error status. This is considered a value object.
	 */
	private static final IStatus STATUS_ERROR= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK,
														  FileBuffersMessages.FileBuffer_status_error, null);
	/**
	 * Constant denoting UTF-8 encoding.
	 */
	private static final String  CHARSET_UTF_8 = "UTF-8"; //$NON-NLS-1$

	/**
	 * Constant denoting an empty set of properties
	 * @since 3.1
	 */
	private static final QualifiedName[] NO_PROPERTIES = new QualifiedName[0];


	/** The element's document */
	protected IDocument fDocument;
	/** The encoding used to create the document from the storage or <code>null</code> for workbench encoding. */
	protected String    fEncoding;
	/** Internal document listener */
	protected IDocumentListener fDocumentListener = new DocumentListener();
	/** The encoding which has explicitly been set on the file. */
	private String           fExplicitEncoding;
	/** Tells whether the file on disk has a BOM. */
	private boolean          fHasBOM;
	/** The annotation model of this file buffer */
	private IAnnotationModel fAnnotationModel;
	/**
	 * Lock for lazy creation of annotation model.
	 * @since 3.2
	 */
	private final Object  fAnnotationModelCreationLock = new Object();
	/**
	 * Tells whether the cache is up to date.
	 * @since 3.2
	 */
	private       boolean fIsCacheUpdated              = false;


	public FileStoreTextFileBuffer(TextFileBufferManager manager) {
		super(manager);
	}

	/*
	 * @see org.eclipse.core.filebuffers.ITextFileBuffer#getDocument()
	 */
	public IDocument getDocument() {
		return fDocument;
	}

	/*
	 * @see org.eclipse.core.filebuffers.ITextFileBuffer#getAnnotationModel()
	 */
	public IAnnotationModel getAnnotationModel() {
//		synchronized (fAnnotationModelCreationLock) {
//			if (fAnnotationModel == null && !isDisconnected()) {
//				fAnnotationModel = fManager.createAnnotationModel(getLocationOrName(), LocationKind.LOCATION);
//				if (fAnnotationModel != null)
//					fAnnotationModel.connect(fDocument);
//			}
//		}
//		return fAnnotationModel;
		throw new UnsupportedOperationException("getAnnotationModel");
	}

	/*
	 * @see org.eclipse.core.filebuffers.ITextFileBuffer#getEncoding()
	 */
	public String getEncoding() {
		if (!fIsCacheUpdated)
			cacheEncodingState();
		return fEncoding;
	}

	/*
	 * @see org.eclipse.core.filebuffers.ITextFileBuffer#setEncoding(java.lang.String)
	 */
	public void setEncoding(String encoding) {
		fExplicitEncoding = encoding;
		if (encoding == null || encoding.equals(fEncoding))
			fIsCacheUpdated = false;
		else {
			fEncoding = encoding;
			fHasBOM = false;
		}
	}

	/*
	 * @see org.eclipse.core.filebuffers.ITextFileBuffer#getStatus()
	 */
	public IStatus getStatus() {
		if (!isDisconnected()) {
			if (fStatus != null)
				return fStatus;
			return (fDocument == null ? STATUS_ERROR : Status.OK_STATUS);
		}
		return STATUS_ERROR;
	}

	private InputStream getFileContents(IFileStore fileStore) throws CoreException {
		if (!fFileStore.fetchInfo().exists())
			return null;

		return fileStore.openInputStream(EFS.NONE, null);
	}

	private void setFileContents(InputStream stream, IProgressMonitor monitor) throws CoreException {
		OutputStream out = fFileStore.openOutputStream(EFS.NONE, null);
		try {
			byte[] buffer = new byte[8192];
			while (true) {
				int bytesRead = -1;
				bytesRead = stream.read(buffer);
				if (bytesRead == -1) {
					out.close();
					break;
				}
				out.write(buffer, 0, bytesRead);
				if (monitor != null)
					monitor.worked(1);
			}
		} catch (IOException ex) {
			String message = (ex.getMessage() != null ? ex.getMessage() : ""); //$NON-NLS-1$
			IStatus s = new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, message, ex);
			throw new CoreException(s);
		} finally {
			try {
				stream.close();
			} catch (IOException e) {
			} finally {
				try {
					out.close();
				} catch (IOException e) {
				}
			}
		}
	}

	/*
	 * @see org.eclipse.core.filebuffers.IFileBuffer#revert(org.eclipse.core.runtime.IProgressMonitor)
	 */
	public void revert(IProgressMonitor monitor) throws CoreException {
		if (isDisconnected())
			return;

		IDocument original = null;
		fStatus = null;

		try {
			original = fManager.createEmptyDocument(getLocationOrName(), LocationKind.LOCATION);
			cacheEncodingState();
			setDocumentContent(original, fFileStore, fEncoding, fHasBOM, monitor);
		} catch (CoreException x) {
			fStatus = x.getStatus();
		}

		if (original == null)
			return;

		String originalContents = original.get();
		boolean replaceContents = !originalContents.equals(fDocument.get());

		if (!replaceContents && !fCanBeSaved)
			return;

		fManager.fireStateChanging(this);
		try {

			if (replaceContents) {
				fManager.fireBufferContentAboutToBeReplaced(this);
				fDocument.set(original.get());
			}

			boolean fireDirtyStateChanged = fCanBeSaved;
			if (fCanBeSaved) {
				fCanBeSaved = false;
				addFileBufferContentListeners();
			}

			if (replaceContents)
				fManager.fireBufferContentReplaced(this);

			IFileInfo info = fFileStore.fetchInfo();
			if (info.exists())
				fSynchronizationStamp = fFileStore.fetchInfo().getLastModified();

//			if (fAnnotationModel instanceof IPersistableAnnotationModel) {
//				IPersistableAnnotationModel persistableModel = (IPersistableAnnotationModel)fAnnotationModel;
//				try {
//					persistableModel.revert(fDocument);
//				} catch (CoreException x) {
//					fStatus = x.getStatus();
//				}
//			}

			if (fireDirtyStateChanged)
				fManager.fireDirtyStateChanged(this, fCanBeSaved);

		} catch (RuntimeException x) {
			fManager.fireStateChangeFailed(this);
			throw x;
		}
	}

	/*
	 * @see org.eclipse.core.filebuffers.IFileBuffer#getContentType()
	 * @since 3.1
	 */
	public IContentType getContentType() throws CoreException {
		InputStream stream = null;
		try {
			if (isDirty()) {
				Reader reader = new DocumentReader(getDocument());
				try {
					IContentDescription desc =
							Platform.getContentTypeManager().getDescriptionFor(reader, fFileStore.getName(), NO_PROPERTIES);
					if (desc != null && desc.getContentType() != null)
						return desc.getContentType();
				} finally {
					try {
						reader.close();
					} catch (IOException ex) {
					}
				}
			}
			stream= fFileStore.openInputStream(EFS.NONE, null);
			IContentDescription desc= Platform.getContentTypeManager().getDescriptionFor(stream, fFileStore.getName(), NO_PROPERTIES);
			if (desc != null && desc.getContentType() != null)
				return desc.getContentType();
			return null;
		} catch (IOException x) {
			throw new CoreException(new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, NLSUtility
					.format(FileBuffersMessages.FileBuffer_error_queryContentDescription, fFileStore.toString()), x));
		} finally {
			try {
				if (stream != null)
					stream.close();
			} catch (IOException x) {
			}
		}
	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.FileBuffer#addFileBufferContentListeners()
	 */
	protected void addFileBufferContentListeners() {
		if (fDocument != null)
			fDocument.addDocumentListener(fDocumentListener);
	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.FileBuffer#removeFileBufferContentListeners()
	 */
	protected void removeFileBufferContentListeners() {
		if (fDocument != null)
			fDocument.removeDocumentListener(fDocumentListener);
	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.FileBuffer#initializeFileBufferContent(org.eclipse.core.runtime.IProgressMonitor)
	 */
	protected void initializeFileBufferContent(IProgressMonitor monitor) throws CoreException {
		try {
			fDocument= fManager.createEmptyDocument(getLocationOrName(), LocationKind.LOCATION);
//			cacheEncodingState();
			setDocumentContent(fDocument, fFileStore, fEncoding, fHasBOM, monitor);
		} catch (CoreException x) {
			fDocument= fManager.createEmptyDocument(getLocationOrName(), LocationKind.LOCATION);
			fStatus= x.getStatus();
		}
	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.ResourceFileBuffer#connected()
	 */
	protected void connected() {
		super.connected();
		if (fAnnotationModel != null)
			fAnnotationModel.connect(fDocument);
	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.ResourceFileBuffer#disconnected()
	 */
	protected void disconnected() {
		if (fAnnotationModel != null)
			fAnnotationModel.disconnect(fDocument);
		super.disconnected();
	}

	protected void cacheEncodingState() {
		fEncoding= fExplicitEncoding;
		fHasBOM= false;
		fIsCacheUpdated= true;

		InputStream stream= null;
		try {
			stream= getFileContents(fFileStore);
			if (stream == null)
				return;

			QualifiedName[] options= new QualifiedName[] { IContentDescription.CHARSET, IContentDescription.BYTE_ORDER_MARK };
			IContentDescription description=  null;//Platform.getContentTypeManager().getDescriptionFor(stream, fFileStore.getName(), options);
			if (description != null) {
				fHasBOM= description.getProperty(IContentDescription.BYTE_ORDER_MARK) != null;
				if (fEncoding == null)
					fEncoding= description.getCharset();
			}
		} catch (CoreException e) {
			// do nothing
		} /*catch (IOException e) {
			// do nothing
		}*/ finally {
			try {
				if (stream != null)
					stream.close();
			} catch (IOException ex) {
				FileBuffersPlugin
						.getDefault().log(new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, FileBuffersMessages.JavaTextFileBuffer_error_closeStream, ex));
			}
		}

		// Use global default
		if (fEncoding == null)
			fEncoding= fManager.getDefaultEncoding();

	}

	/*
	 * @see org.eclipse.core.internal.filebuffers.FileBuffer#commitFileBufferContent(org.eclipse.core.runtime.IProgressMonitor, boolean)
	 */
	protected void commitFileBufferContent(IProgressMonitor monitor, boolean overwrite) throws CoreException {
//		if (!isSynchronized() && !overwrite)
//			throw new CoreException(new Status(IStatus.WARNING, FileBuffersPlugin.PLUGIN_ID, IResourceStatus.OUT_OF_SYNC_LOCAL, FileBuffersMessages.FileBuffer_error_outOfSync, null));

		String encoding= computeEncoding();

		Charset charset;
		try {
			charset= Charset.forName(encoding);
		} catch (UnsupportedCharsetException ex) {
			String message= NLSUtility.format(FileBuffersMessages.ResourceTextFileBuffer_error_unsupported_encoding_message_arg, encoding);
			IStatus s= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, message, ex);
			throw new CoreException(s);
		} catch (IllegalCharsetNameException ex) {
			String message= NLSUtility.format(FileBuffersMessages.ResourceTextFileBuffer_error_illegal_encoding_message_arg, encoding);
			IStatus s= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, message, ex);
			throw new CoreException(s);
		}

		CharsetEncoder encoder= charset.newEncoder();
		encoder.onMalformedInput(CodingErrorAction.REPLACE);
		encoder.onUnmappableCharacter(CodingErrorAction.REPORT);

		byte[] bytes;
		int bytesLength;

		try {
			ByteBuffer byteBuffer= encoder.encode(CharBuffer.wrap(fDocument.get()));
			bytesLength= byteBuffer.limit();
			if (byteBuffer.hasArray())
				bytes= byteBuffer.array();
			else {
				bytes= new byte[bytesLength];
				byteBuffer.get(bytes);
			}
		} catch (CharacterCodingException ex) {
			Assert.isTrue(ex instanceof UnmappableCharacterException);
			String message= NLSUtility.format(FileBuffersMessages.ResourceTextFileBuffer_error_charset_mapping_failed_message_arg, encoding);
			IStatus s= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IFileBufferStatusCodes.CHARSET_MAPPING_FAILED, message, null);
			throw new CoreException(s);
		}

		IFileInfo fileInfo= fFileStore.fetchInfo();
		if (fileInfo != null && fileInfo.exists()) {

			if (!overwrite)
				checkSynchronizationState();

			InputStream stream= new ByteArrayInputStream(bytes, 0, bytesLength);

			/*
			 * XXX:
			 * This is a workaround for a corresponding bug in Java readers and writer,
			 * see http://developer.java.sun.com/developer/bugParade/bugs/4508058.html
			 */
			if (fHasBOM && CHARSET_UTF_8.equals(encoding))
				stream= new SequenceInputStream(new ByteArrayInputStream(IContentDescription.BOM_UTF_8), stream);


			// here the file synchronizer should actually be removed and afterwards added again. However,
			// we are already inside an operation, so the delta is sent AFTER we have added the listener
			setFileContents(stream, monitor);
			// set synchronization stamp to know whether the file synchronizer must become active
			fSynchronizationStamp= fFileStore.fetchInfo().getLastModified();

//			if (fAnnotationModel instanceof IPersistableAnnotationModel) {
//				IPersistableAnnotationModel persistableModel= (IPersistableAnnotationModel) fAnnotationModel;
//				persistableModel.commit(fDocument);
//			}

		} else {
			fFileStore.getParent().mkdir(EFS.NONE, null);
			OutputStream out= fFileStore.openOutputStream(EFS.NONE, null);
			try {
				/*
				 * XXX:
				 * This is a workaround for a corresponding bug in Java readers and writer,
				 * see http://developer.java.sun.com/developer/bugParade/bugs/4508058.html
				 */
				if (fHasBOM && CHARSET_UTF_8.equals(encoding))
					out.write(IContentDescription.BOM_UTF_8);

				out.write(bytes, 0, bytesLength);
				out.flush();
				out.close();
			} catch (IOException x) {
				IStatus s= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, x.getLocalizedMessage(), x);
				throw new CoreException(s);
			} finally {
				try {
					out.close();
				} catch (IOException x) {
				}
			}

			// set synchronization stamp to know whether the file synchronizer must become active
			fSynchronizationStamp= fFileStore.fetchInfo().getLastModified();

		}
	}

	private String computeEncoding() {
		// Make sure cache is up to date
		if (!fIsCacheUpdated)
			cacheEncodingState();

		// User-defined encoding has first priority
		if (fExplicitEncoding != null)
			return fExplicitEncoding;

//		// Probe content
//		Reader reader= new DocumentReader(fDocument);
//		try {
//			QualifiedName[] options= new QualifiedName[] { IContentDescription.CHARSET, IContentDescription.BYTE_ORDER_MARK };
//			IContentDescription description= Platform.getContentTypeManager().getDescriptionFor(reader, fFileStore.getName(), options);
//			if (description != null) {
//				String encoding= description.getCharset();
//				if (encoding != null)
//					return encoding;
//			}
//		} catch (IOException ex) {
//			// Try next strategy
//		} finally {
//			try {
//				reader.close();
//			} catch (IOException x) {
//			}
//		}

		// Use file's encoding if the file has a BOM
		if (fHasBOM)
			return fEncoding;

		// Use global default
		return fManager.getDefaultEncoding();
	}

	/**
	 * Initializes the given document with the given file's content using the given encoding.
	 *
	 * @param document the document to be initialized
	 * @param file the file which delivers the document content
	 * @param encoding the character encoding for reading the given stream
	 * @param hasBOM tell whether the given file has a BOM
	 * @param monitor the progress monitor
	 * @exception CoreException if the given stream can not be read
	 */
	private void setDocumentContent(IDocument document, IFileStore file, String encoding, boolean hasBOM, IProgressMonitor monitor) throws CoreException {
		InputStream contentStream= getFileContents(file);
		if (contentStream == null)
			return;

		Reader in= null;
		try {

			if (encoding == null)
				encoding= fManager.getDefaultEncoding();

			/*
			 * XXX:
			 * This is a workaround for a corresponding bug in Java readers and writer,
			 * see http://developer.java.sun.com/developer/bugParade/bugs/4508058.html
			 */
			if (hasBOM && CHARSET_UTF_8.equals(encoding)) {
				int n= 0;
				do {
					int bytes= contentStream.read(new byte[IContentDescription.BOM_UTF_8.length]);
					if (bytes == -1)
						throw new IOException();
					n += bytes;
				} while (n < IContentDescription.BOM_UTF_8.length);
			}

			in= new BufferedReader(new InputStreamReader(contentStream, encoding), BUFFER_SIZE);
			StringBuffer buffer= new StringBuffer(BUFFER_SIZE);
			char[] readBuffer= new char[READER_CHUNK_SIZE];
			int n= in.read(readBuffer);
			while (n > 0) {
				buffer.append(readBuffer, 0, n);
				n= in.read(readBuffer);
			}

			document.set(buffer.toString());

		} catch (IOException x) {
			String msg= x.getMessage() == null ? "" : x.getMessage(); //$NON-NLS-1$
			IStatus s= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, IStatus.OK, msg, x);
			throw new CoreException(s);
		} finally {
			try {
				if (in != null)
					in.close();
				else
					contentStream.close();
			} catch (IOException x) {
			}
		}
	}

	/**
	 * Checks whether the given file is synchronized with the local file system.
	 * If the file has been changed, a <code>CoreException</code> is thrown.
	 *
	 * @exception CoreException if file has been changed on the file system
	 */
	private void checkSynchronizationState() throws CoreException {
		if (!isSynchronized()) {
			Status status= new Status(IStatus.ERROR, FileBuffersPlugin.PLUGIN_ID, 274 /* IResourceStatus.OUT_OF_SYNC_LOCAL */, FileBuffersMessages.FileBuffer_error_outOfSync, null);
			throw new CoreException(status);
		}
	}

	/**
	 * Returns the location if it is <code>null</code> or
	 * the name as <code>IPath</code> otherwise.
	 *
	 * @return a non-null <code>IPath</code>
	 * @since 3.3.1
	 */
	private IPath getLocationOrName() {
		IPath path= getLocation();
		if (path == null)
			path= new Path(fFileStore.getName());
		return path;
	}
}
